Skip to content

Refactor Core Architecture & Eliminate Polling#522

Open
SuperCoolPencil wants to merge 56 commits into
mainfrom
eliminate-polling
Open

Refactor Core Architecture & Eliminate Polling#522
SuperCoolPencil wants to merge 56 commits into
mainfrom
eliminate-polling

Conversation

@SuperCoolPencil

@SuperCoolPencil SuperCoolPencil commented Jul 1, 2026

Copy link
Copy Markdown
Member

Architecture Refactor: High Level Design

This document visually illustrates the recent structural changes to the Surge architecture, moving from a tightly coupled monolithic engine to a layered, domain-driven design.

The Old Architecture (Monolithic)

In the previous design, the internal/engine package acted as a catch-all monolith. The UI and Core tightly coupled to the engine's internal workings, and state was updated via direct callbacks and polling.

graph TD
    A[TUI / CLI] --> B[internal/core]
    B --> C[internal/processing]
    C --> D((internal/engine))
    
    subgraph Monolithic Engine
        D --> E[engine/concurrent]
        D --> F[engine/single]
        D --> G[engine/transport]
        D --> H[engine/state]
        D --> I[engine/types/progress.go]
    end
    
    E -.-> |Hooks / Polling| C
    F -.-> |Hooks / Polling| C
Loading

The New Architecture (Decoupled & Layered)

The new architecture introduces strict layering and unidirectional data flow. Components are isolated, and the monolithic engine has been decomposed into strategy, scheduler, orchestrator, progress, and store. The EventBus replaces direct callback hooks, and Service provides a clean API boundary.

graph TD
    UI[TUI / CLI] --> API[Service Layer<br>internal/service]
    
    API --> ORCH[Orchestrator Layer<br>internal/orchestrator]
    
    subgraph Core Domain
        ORCH --> SCHED[Scheduler Layer<br>internal/scheduler]
        SCHED --> STRAT[Strategy Layer<br>internal/strategy]
        
        STRAT --> C[concurrent]
        STRAT --> S[single]
        
        STRAT --> PROG[Progress Tracking<br>internal/progress]
        STRAT --> TRANS[Transport / Network<br>internal/transport]
    end
    
    STRAT -.-> |Publishes Typed Events| BUS[EventBus]
    PROG -.-> |Publishes Stats| BUS
    
    BUS -.-> |Subscribed Stream| API
    API -.-> |Real-time Updates| UI
    
    ORCH --> STORE[State / DB Layer<br>internal/store]
Loading

Download Lifecycle Flow

Here is how a download request flows through the new system:

sequenceDiagram
    participant U as User (UI/CLI)
    participant S as Service
    participant O as Orchestrator
    participant SCH as Scheduler
    participant W as Strategy (Worker)
    participant B as EventBus
    
    U->>S: Add(url)
    S->>O: Enqueue Download
    O->>B: Publish(State: Queued)
    O->>SCH: Submit Task
    
    SCH->>W: Execute(Strategy)
    W->>B: Publish(State: Downloading)
    
    loop During Download
        W->>W: Fetch Chunks
        W->>B: Publish(ProgressEvent)
    end
    
    W->>SCH: Complete/Error
    W->>B: Publish(State: Completed)
    SCH->>O: Task Done
Loading

Greptile Summary

This PR replaces the monolithic internal/engine package with a layered, domain-driven architecture: internal/strategy (download algorithms), internal/scheduler (task lifecycle), internal/orchestrator (coordination + EventBus), internal/progress (progress tracking), internal/store (state persistence), and internal/service (public API). Polling and direct callback hooks are eliminated in favour of a typed EventBus and a ProgressAggregator.

  • The new EventBus cleanly separates publishers from subscribers; Shutdown() drains the buffered input channel before closing listener channels, fixing the previous shutdown-ordering bug.
  • enqueueResolved now persists the master-list entry before pool.Add, eliminating the EventStarted/EventQueued status-overwrite race.
  • stateProgress in the TUI correctly narrows msg.State through *types.DownloadRecordprogress.CfgProgress, fixing the always-nil chunk-map rendering.

Confidence Score: 4/5

The core refactor is solid and the most critical shutdown, persistence, and type-assertion bugs from the previous round are all addressed; the sheer breadth of 154 changed files leaves some residual uncertainty about untested edge paths.

Every previously flagged defect that was reproducible from the diff has been corrected: the EventBus drains correctly on shutdown, the master-list entry is persisted before pool.Add eliminating the EventQueued/EventStarted race, getDetailPath takes an explicit dir argument closing the baseDir data-race, buildResumeConfig carries the full task/bitmap state, ResumeBatch no longer early-exits on a single corrupt gob, and stateProgress correctly narrows through *DownloadRecord. The remaining concern is the scale of the change — 154 files across six new packages — which makes it difficult to have full confidence that every interaction has been exercised by the existing tests.

internal/scheduler/scheduler.go (safeSendProgress dead else-branch), internal/orchestrator/progress.go (ProgressAggregator shutdown ordering relative to pool), and the broader interaction between the new strategy/concurrent and strategy/single packages and the updated scheduler worker loop.

Important Files Changed

Filename Overview
internal/orchestrator/event_bus.go New EventBus: drains InputCh via channel-close on Shutdown (no ctx.Done in the broadcast loop), correctly preventing the previous drain-race; pubWg guards prevent InputCh from being closed while Publish is in-flight.
internal/orchestrator/manager.go Shutdown ordering is now correct (aggregator → pool.GracefulShutdown → eventBus.Shutdown); synchronous master-list persist before pool.Add eliminates the EventStarted/EventQueued ordering race.
internal/orchestrator/events.go EventQueued handler now only writes if the entry is absent, guarding against status regression when EventStarted is already persisted; EventPaused fallback path correctly uses advanceRemainingTasks to keep chunk boundaries aligned.
internal/orchestrator/pause_resume.go buildResumeConfig now correctly copies tasks, chunkBitmap, and actualChunkSize from savedState; ResumeBatch cold path loads the master list as a fallback for downloads without a detail-state gob, eliminating the nil-panic and the "not found" false-error for queued downloads.
internal/store/db.go ensureDirs() now snapshots baseDir/configured under masterMu.RLock() before calling ensureDirsInternal; cleanupOnce closure captures the local dir variable (not the package-level baseDir), closing the previous data-race window.
internal/store/state.go getDetailPath() now takes an explicit dir argument; all callers snapshot baseDir under masterMu before passing it, eliminating the previously flagged concurrent-read races. LoadStates uses errors.Join and no longer early-returns on first error.
internal/scheduler/scheduler.go GracefulShutdown correctly drains taskChan with matching wg.Done() calls and polls until IsPausing() is false before closing progressDone; safeSendProgress blocking else-branch is effectively dead code for current call sites but remains a latent hazard if doneCh==nil.
internal/tui/update_events.go stateProgress() now correctly narrows msg.State through *types.DownloadRecord → engineprogress.CfgProgress, fixing the always-nil chunk-map rendering from the previous wrong-type assertion.
internal/orchestrator/progress.go New ProgressAggregator polls the scheduler's in-memory state on a 150 ms ticker and publishes EventBatchProgress; correctly skips paused and done downloads, and its Shutdown cancels the goroutine before pool.GracefulShutdown runs.
internal/service/local_service.go ReloadSettings now correctly delegates to lifecycle.ApplySettings instead of returning nil unconditionally, fixing the previously flagged silent no-op.

Comments Outside Diff (12)

  1. internal/orchestrator/pause_resume.go, line 211-232 (link)

    P1 ResumeBatch cold path fails for downloads without a detail state file

    store.LoadStates(coldIDs) only reads from details/<id>.gob files written by SaveStateWithOptions (i.e., downloads that have been through at least one EventPaused cycle). Queued downloads that never started, and errored downloads that never saved a pause snapshot, have no detail file. For these IDs states[id] will be absent, and ResumeBatch returns "download not found or completed".

    In contrast, the single Resume path calls store.GetDownload(id) first and succeeds even when LoadState fails, because buildResumeConfig accepts a nil savedState and falls back to the master-list entry. The batch path should do the same: supplement LoadStates with a batch master-list lookup and fall through to buildResumeConfig(id, outputPath, entry, nil, settings) for IDs not present in the detail map.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: internal/orchestrator/pause_resume.go
    Line: 211-232
    
    Comment:
    **`ResumeBatch` cold path fails for downloads without a detail state file**
    
    `store.LoadStates(coldIDs)` only reads from `details/<id>.gob` files written by `SaveStateWithOptions` (i.e., downloads that have been through at least one `EventPaused` cycle). Queued downloads that never started, and errored downloads that never saved a pause snapshot, have no detail file. For these IDs `states[id]` will be absent, and `ResumeBatch` returns `"download not found or completed"`.
    
    In contrast, the single `Resume` path calls `store.GetDownload(id)` first and succeeds even when `LoadState` fails, because `buildResumeConfig` accepts a `nil` `savedState` and falls back to the master-list `entry`. The batch path should do the same: supplement `LoadStates` with a batch master-list lookup and fall through to `buildResumeConfig(id, outputPath, entry, nil, settings)` for IDs not present in the detail map.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. internal/orchestrator/pause_resume.go, line 246-252 (link)

    P1 savedState is nil when a download lives only in the master list (queued but never started or saved a pause snapshot). At this point buildResumeConfig succeeds because it falls back to entry, but the immediately following savedState.Filename dereference panics. Any ResumeBatch call that includes a download that was queued and never executed (e.g., the app was killed before the scheduler ran it) will crash the process.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: internal/orchestrator/pause_resume.go
    Line: 246-252
    
    Comment:
    `savedState` is nil when a download lives only in the master list (queued but never started or saved a pause snapshot). At this point `buildResumeConfig` succeeds because it falls back to `entry`, but the immediately following `savedState.Filename` dereference panics. Any `ResumeBatch` call that includes a download that was queued and never executed (e.g., the app was killed before the scheduler ran it) will crash the process.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
  3. internal/orchestrator/manager.go, line 331-342 (link)

    P1 Silently dropped EventQueued means download is lost on restart

    Publish returns context.DeadlineExceeded after 1 second if InputCh is full; calling code discards the error with _ =. When this happens the download is already in the scheduler pool but StartEventWorker never sees EventQueued, so store.AddToMasterList is never called. The in-memory entry disappears on the next restart and the user has no indication the download was lost. The pool is also cleared by GracefulShutdown before state is written, so there's no recovery path.

    InputCh can fill up when broadcastLoop is delayed by slow subscribers (each non-progress event waits up to 1 s per subscriber in broadcastMsg), making this reachable without a crash — a single lagging TUI or HTTP SSE client is sufficient.

    Consider logging or surfacing a non-nil Publish error here, or using a direct store.AddToMasterList call as a synchronous fallback before the event is dispatched.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: internal/orchestrator/manager.go
    Line: 331-342
    
    Comment:
    **Silently dropped `EventQueued` means download is lost on restart**
    
    `Publish` returns `context.DeadlineExceeded` after 1 second if `InputCh` is full; calling code discards the error with `_ =`. When this happens the download is already in the scheduler pool but `StartEventWorker` never sees `EventQueued`, so `store.AddToMasterList` is never called. The in-memory entry disappears on the next restart and the user has no indication the download was lost. The pool is also cleared by `GracefulShutdown` before state is written, so there's no recovery path.
    
    `InputCh` can fill up when `broadcastLoop` is delayed by slow subscribers (each non-progress event waits up to 1 s per subscriber in `broadcastMsg`), making this reachable without a crash — a single lagging TUI or HTTP SSE client is sufficient.
    
    Consider logging or surfacing a non-nil `Publish` error here, or using a direct `store.AddToMasterList` call as a synchronous fallback before the event is dispatched.
    
    How can I resolve this? If you propose a fix, please make it concise.
  4. internal/scheduler/manager.go, line 69-71 (link)

    P1 When all 100 candidate names are already occupied, uniqueFilePath silently returns the original path — which is known to conflict (the existence check at the top of the function confirmed it). Any caller expecting a non-conflicting path from this function would silently overwrite an existing file. The fallback should either return an error-sentinel empty string or append a timestamp/random suffix to guarantee a unique result.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: internal/scheduler/manager.go
    Line: 69-71
    
    Comment:
    When all 100 candidate names are already occupied, `uniqueFilePath` silently returns the original `path` — which is known to conflict (the existence check at the top of the function confirmed it). Any caller expecting a non-conflicting path from this function would silently overwrite an existing file. The fallback should either return an error-sentinel empty string or append a timestamp/random suffix to guarantee a unique result.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
  5. cmd/root.go, line 114-124 (link)

    P1 Unguarded type assertion on ProgressState will panic on unexpected types

    cfg.ProgressState.(*progress.DownloadProgress) is used without the comma-ok form in two places here. Because ProgressState is typed as interface{}, any value that is not *progress.DownloadProgress triggers a panic inside buildActiveDownloadChecker. The rest of the codebase consistently uses progress.CfgProgress(&cfg) for this narrowing, which handles nil and wrong-type gracefully — the new code should use the same helper.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: cmd/root.go
    Line: 114-124
    
    Comment:
    **Unguarded type assertion on `ProgressState` will panic on unexpected types**
    
    `cfg.ProgressState.(*progress.DownloadProgress)` is used without the comma-ok form in two places here. Because `ProgressState` is typed as `interface{}`, any value that is not `*progress.DownloadProgress` triggers a panic inside `buildActiveDownloadChecker`. The rest of the codebase consistently uses `progress.CfgProgress(&cfg)` for this narrowing, which handles nil and wrong-type gracefully — the new code should use the same helper.
    
    How can I resolve this? If you propose a fix, please make it concise.
  6. internal/store/db.go, line 31-35 (link)

    P1 Data race: configured and baseDir read without a lock in ensureDirs

    Configure and CloseDB write configured and baseDir under masterMu, but ensureDirs reads both without holding any lock. When SaveStateWithOptions calls ensureDirs before acquiring masterMu, a concurrent Configure or CloseDB call races with these reads. The Go race detector will flag this. Holding masterMu.RLock() for the read-only check at the top of ensureDirs would close the race.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: internal/store/db.go
    Line: 31-35
    
    Comment:
    **Data race: `configured` and `baseDir` read without a lock in `ensureDirs`**
    
    `Configure` and `CloseDB` write `configured` and `baseDir` under `masterMu`, but `ensureDirs` reads both without holding any lock. When `SaveStateWithOptions` calls `ensureDirs` before acquiring `masterMu`, a concurrent `Configure` or `CloseDB` call races with these reads. The Go race detector will flag this. Holding `masterMu.RLock()` for the read-only check at the top of `ensureDirs` would close the race.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
  7. internal/store/db.go, line 43-48 (link)

    P1 Residual data race: cleanupOrphans(baseDir) reads package variable without a lock

    The PR applied the previous review's suggestion by reading configured and baseDir under masterMu.RLock() and snapshotting them into isConfigured/dir — but the cleanupOnce.Do closure at line 45 reads the package variable baseDir directly instead of the captured dir. This is still a concurrent read without the lock.

    Race scenario: Thread A releases masterMu.RUnlock() with dir="/path1", Thread B calls CloseDB (sets baseDir="") then Configure with /path2 (sets baseDir="/path2"), Thread A enters cleanupOnce.Do and calls cleanupOrphans("/path2") — cleaning temp files from an unrelated base directory. On Linux, if baseDir is "" when the closure fires, os.ReadDir("") traverses the current working directory and can delete any .tmp-* files found there.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: internal/store/db.go
    Line: 43-48
    
    Comment:
    **Residual data race: `cleanupOrphans(baseDir)` reads package variable without a lock**
    
    The PR applied the previous review's suggestion by reading `configured` and `baseDir` under `masterMu.RLock()` and snapshotting them into `isConfigured`/`dir` — but the `cleanupOnce.Do` closure at line 45 reads the package variable `baseDir` directly instead of the captured `dir`. This is still a concurrent read without the lock.
    
    Race scenario: Thread A releases `masterMu.RUnlock()` with `dir="/path1"`, Thread B calls `CloseDB` (sets `baseDir=""`) then `Configure` with `/path2` (sets `baseDir="/path2"`), Thread A enters `cleanupOnce.Do` and calls `cleanupOrphans("/path2")` — cleaning temp files from an unrelated base directory. On Linux, if `baseDir` is `""` when the closure fires, `os.ReadDir("")` traverses the current working directory and can delete any `.tmp-*` files found there.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
  8. internal/orchestrator/manager.go, line 312-362 (link)

    P1 EventStarted can arrive in InputCh before EventQueued, causing status corruption

    dispatchToScheduler at line 312 calls mgr.pool.Add(cfg), which may immediately schedule a pool worker. That worker calls safeSendProgress(cfg.ProgressCh, EventStarted) — writing directly to InputCh — before enqueueResolved reaches the store.AddToMasterList call at line 342 or the Publish(EventQueued) call at line 358. The broadcastLoop then serialises them for StartEventWorker in arrival order: EventStarted is processed first (sets status "downloading"), then EventQueued is processed second (unconditional AddToMasterList with Status: "queued" at events.go:331), overwriting the correct status. Any crash between the EventQueued write and the next lifecycle event (EventComplete/EventPaused/EventError) leaves the DB entry as "queued" for a download that had already started, preventing correct crash-recovery resume.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: internal/orchestrator/manager.go
    Line: 312-362
    
    Comment:
    **EventStarted can arrive in InputCh before EventQueued, causing status corruption**
    
    `dispatchToScheduler` at line 312 calls `mgr.pool.Add(cfg)`, which may immediately schedule a pool worker. That worker calls `safeSendProgress(cfg.ProgressCh, EventStarted)` — writing directly to `InputCh` — before `enqueueResolved` reaches the `store.AddToMasterList` call at line 342 or the `Publish(EventQueued)` call at line 358. The `broadcastLoop` then serialises them for `StartEventWorker` in arrival order: `EventStarted` is processed first (sets status `"downloading"`), then `EventQueued` is processed second (unconditional `AddToMasterList` with `Status: "queued"` at `events.go:331`), overwriting the correct status. Any crash between the `EventQueued` write and the next lifecycle event (`EventComplete`/`EventPaused`/`EventError`) leaves the DB entry as `"queued"` for a download that had already started, preventing correct crash-recovery resume.
    
    How can I resolve this? If you propose a fix, please make it concise.
  9. internal/store/state.go, line 102-113 (link)

    P1 getDetailPath(state.ID) at line 106 reads the package-level baseDir variable without holding masterMu. Between the ensureDirs() call (which acquires and releases masterMu.RLock()) and the masterMu.Lock() at line 112, a concurrent CloseDB() or Configure() can modify baseDir. If CloseDB fires in that window, getDetailPath computes a path rooted at "", and atomicWrite creates or reads a file relative to the current working directory. The Go race detector will flag this. The fix is to hold the master lock for the entire write sequence.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: internal/store/state.go
    Line: 102-113
    
    Comment:
    `getDetailPath(state.ID)` at line 106 reads the package-level `baseDir` variable without holding `masterMu`. Between the `ensureDirs()` call (which acquires and releases `masterMu.RLock()`) and the `masterMu.Lock()` at line 112, a concurrent `CloseDB()` or `Configure()` can modify `baseDir`. If `CloseDB` fires in that window, `getDetailPath` computes a path rooted at `""`, and `atomicWrite` creates or reads a file relative to the current working directory. The Go race detector will flag this. The fix is to hold the master lock for the entire write sequence.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.
  10. internal/store/state.go, line 254-273 (link)

    P1 getDetailPath reads baseDir without holding masterMu in LoadStates

    LoadStates is called entirely outside any lock. Inside the loop, getDetailPath(id) reads the package-level baseDir variable without any synchronization. If a concurrent CloseDB() fires between two iterations, baseDir becomes "" and getDetailPath returns "details/<id>.gob" — a relative path resolved against the current working directory. Any .gob file found there would be silently decoded and returned as valid resume state, corrupting the batch-resume result.

    The same race exists in LoadState at line 243 (loadGob(getDetailPath(foundID), &ds)), which executes after LoadMasterList() has released its RLock. Both functions should snapshot baseDir under the read-lock before constructing the path, or delegate path construction to a caller-provided dir argument the way ensureDirsInternal does.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: internal/store/state.go
    Line: 254-273
    
    Comment:
    **`getDetailPath` reads `baseDir` without holding `masterMu` in `LoadStates`**
    
    `LoadStates` is called entirely outside any lock. Inside the loop, `getDetailPath(id)` reads the package-level `baseDir` variable without any synchronization. If a concurrent `CloseDB()` fires between two iterations, `baseDir` becomes `""` and `getDetailPath` returns `"details/<id>.gob"` — a relative path resolved against the current working directory. Any `.gob` file found there would be silently decoded and returned as valid resume state, corrupting the batch-resume result.
    
    The same race exists in `LoadState` at line 243 (`loadGob(getDetailPath(foundID), &ds)`), which executes after `LoadMasterList()` has released its `RLock`. Both functions should snapshot `baseDir` under the read-lock before constructing the path, or delegate path construction to a caller-provided `dir` argument the way `ensureDirsInternal` does.
    
    How can I resolve this? If you propose a fix, please make it concise.
  11. internal/store/state.go, line 106 (link)

    P1 getDetailPath reads baseDir without masterMu in three call sites

    ensureDirs() at line 102 acquires and releases masterMu.RLock() and then returns. At line 106, getDetailPath(state.ID) reads the package-level baseDir variable again — this time without any lock. A concurrent CloseDB() or Configure() call between the two reads can change baseDir, causing atomicWrite to create or read a .gob file relative to an unintended directory (or the current working directory when baseDir is "").

    The same race exists in LoadState (calls getDetailPath after LoadMasterList() releases its RLock) and in LoadStates (calls getDetailPath in a loop, entirely lock-free). The Go race detector will flag all three.

    Fix: snapshot baseDir under masterMu.RLock() (or hold it) before calling getDetailPath, as already done in ensureDirs and DeleteState.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: internal/store/state.go
    Line: 106
    
    Comment:
    **`getDetailPath` reads `baseDir` without `masterMu` in three call sites**
    
    `ensureDirs()` at line 102 acquires and releases `masterMu.RLock()` and then returns. At line 106, `getDetailPath(state.ID)` reads the package-level `baseDir` variable again — this time without any lock. A concurrent `CloseDB()` or `Configure()` call between the two reads can change `baseDir`, causing `atomicWrite` to create or read a `.gob` file relative to an unintended directory (or the current working directory when `baseDir` is `""`).
    
    The same race exists in `LoadState` (calls `getDetailPath` after `LoadMasterList()` releases its `RLock`) and in `LoadStates` (calls `getDetailPath` in a loop, entirely lock-free). The Go race detector will flag all three.
    
    Fix: snapshot `baseDir` under `masterMu.RLock()` (or hold it) before calling `getDetailPath`, as already done in `ensureDirs` and `DeleteState`.
    
    How can I resolve this? If you propose a fix, please make it concise.
  12. internal/scheduler/scheduler.go, line 638-662 (link)

    P1 safeSendProgress blocks indefinitely during shutdown if InputCh is full

    safeSendProgress is a raw blocking send with no timeout or context. When a single-threaded download is paused during GracefulShutdown, the worker calls safeSendProgress(localCfg.ProgressCh, ...) before calling p.wg.Done(). If eventBus.InputCh (capacity 100) is completely full at that moment — possible when many downloads are pausing simultaneously while the broadcast loop is delayed by slow subscribers (each non-progress event waits up to 1 second per subscriber in broadcastMsg) — the worker goroutine blocks here indefinitely. pool.wg.Wait() never returns, and GracefulShutdown hangs forever.

    The concurrent downloader avoids this by sending EventPaused before RunDownload returns nil, so the blocking path only affects single-threaded downloads. Consider replacing the raw send with a select that also reads from the existing progressDone channel (already used for shutdown signalling in the scheduler), so the worker can bail out if the bus is unresponsive.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: internal/scheduler/scheduler.go
    Line: 638-662
    
    Comment:
    **`safeSendProgress` blocks indefinitely during shutdown if `InputCh` is full**
    
    `safeSendProgress` is a raw blocking send with no timeout or context. When a single-threaded download is paused during `GracefulShutdown`, the worker calls `safeSendProgress(localCfg.ProgressCh, ...)` before calling `p.wg.Done()`. If `eventBus.InputCh` (capacity 100) is completely full at that moment — possible when many downloads are pausing simultaneously while the broadcast loop is delayed by slow subscribers (each non-progress event waits up to 1 second per subscriber in `broadcastMsg`) — the worker goroutine blocks here indefinitely. `pool.wg.Wait()` never returns, and `GracefulShutdown` hangs forever.
    
    The concurrent downloader avoids this by sending `EventPaused` before `RunDownload` returns nil, so the blocking path only affects single-threaded downloads. Consider replacing the raw send with a `select` that also reads from the existing `progressDone` channel (already used for shutdown signalling in the scheduler), so the worker can bail out if the bus is unresponsive.
    
    How can I resolve this? If you propose a fix, please make it concise.

Reviews (18): Last reviewed commit: "refactor: ensure safe event bus shutdown..." | Re-trigger Greptile

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

Binary Size Analysis

⚠️ Size Increased

Version Human Readable Raw Bytes
Main 17.00 MB 17826084
PR 17.02 MB 17842468
Difference 16.00 KB 16384

Comment thread internal/orchestrator/manager.go
Comment thread internal/orchestrator/manager.go Outdated
Comment thread internal/service/local_service.go
Comment thread internal/types/events.go Outdated
Comment thread internal/orchestrator/event_bus.go Outdated
Comment thread internal/orchestrator/pause_resume.go
Comment thread internal/orchestrator/pause_resume.go Outdated
Comment thread internal/tui/update_events.go
…ertions, and implement synchronous download persistence
…d persistence by checking for existence first
- cmd/rm.go: fix errcheck lint violations by wrapping resp.Body.Close()
  in anonymous funcs so the error is explicitly discarded

- internal/utils/debug.go: add CloseDebug() to close the open log file
  handle and reset sync.Once; protect Debug() writes with debugMu so
  CloseDebug() and Debug() are race-safe

- cmd/test_env_test.go: call utils.CloseDebug() in setupXDGEnvIsolation
  cleanup so Windows can remove the temp dir (the open debug log file
  was causing 'file in use' errors during t.TempDir() cleanup)

- cmd/cli_test.go: change net.Listen('tcp', ...) to 'tcp4' and replace
  t.Fatalf with t.Skipf on bind failure; mirrors the existing
  testutil.NewHTTPServerT behavior for runners where the dual-stack
  socket provider is unavailable

- cmd/get_test.go: replace httptest.NewServer (which tries IPv6 first
  and panics) with testutil.NewHTTPServerT in startAuthedTestServer;
  add testutil import, remove unused httptest import
…ess test

TestHandleDownload_HeadlessMode_AutoApprovesNonDuplicate used
httptest.NewServer for the probe server, which tries to bind an IPv6
listener first and panics on Windows CI runners where the dual-stack
socket service provider is unavailable.

Replace with testutil.NewHTTPServerT (tcp4, skips on bind failure) to
match the pattern used by the other tests fixed in the previous commit.
…HTTPServerT

Four more bare httptest.NewServer calls were using Go's newLocalListener()
which tries tcp6 [::1]:0 first and panics on Windows CI runners lacking the
dual-stack socket provider:

- cmd/http_api_test.go:259  TestEventsEndpoint_RequiresAuthAndStreamsSSE
- cmd/http_api_test.go:619  TestExecuteAPIAction_SendsIDAsQueryParam
- cmd/http_handler_test.go:300  (probe server for download handler test)
- cmd/http_handler_test.go:435  (probe server for download handler test)

Replace all with testutil.NewHTTPServerT (binds tcp4, skips gracefully on
failure). Add testutil import to both files.
In a recent commit, the \Start\ method of \program\ was updated to forcibly
set \os.Args\ to \server start\ to ensure the daemon mode starts correctly
from the service manager. However, this means \TestProgramLifecycle\ and
\TestProgramContextCancellation\ now actually run the background server logic
during tests instead of executing a harmless \--help\ run.

This surfaces the same dual-stack \	cp\ provider error on \windows-latest\
as the HTTP API tests when the server attempts to bind to a port, leading to
\could not find available port\.

Add a pre-check to gracefully skip both tests if the \	cp\ socket provider
fails to initialize, matching the skip pattern established in \cli_test.go\
and \get_test.go\.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant